# -*- coding: utf-8 -*-
# Calibre-Web Automated – fork of Calibre-Web
# Copyright (C) 2018-2025 Calibre-Web contributors
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
# SPDX-License-Identifier: GPL-3.0-or-later
# See CONTRIBUTORS for full list of authors.

import sys
import os
import datetime
import json
import shutil
import threading
import time
import zipfile
from io import BytesIO
import requests

from flask_babel import format_datetime
from flask_babel import gettext as _

from . import constants, logger  #  config, web_server
from .file_helper import get_temp_dir


log = logger.create()
_REPOSITORY_API_URL = 'https://api.github.com/repos/janeczku/calibre-web'


def is_sha1(sha1):
    if len(sha1) != 40:
        return False
    try:
        int(sha1, 16)
    except ValueError:
        return False
    return True


class Updater(threading.Thread):

    def __init__(self):
        threading.Thread.__init__(self)
        self.web_server = None
        self.config = None
        self.paused = False
        self.can_run = threading.Event()
        self.pause()
        self.status = -1
        self.updateIndex = None

    def init_updater(self, config, web_server):
        self.config = config
        self.web_server = web_server

    def get_current_version_info(self):
        if self.config.config_updatechannel == constants.UPDATE_STABLE:
            return self._stable_version_info()
        return self._nightly_version_info()

    def get_available_updates(self, request_method):
        if self.config.config_updatechannel == constants.UPDATE_STABLE:
            return self._stable_available_updates(request_method)
        return self._nightly_available_updates(request_method)

    def do_work(self):
        try:
            self.status = 1
            log.debug('Download update file')
            headers = {'Accept': 'application/vnd.github.v3+json'}
            r = requests.get(self._get_request_path(), stream=True, headers=headers, timeout=(10, 600))
            r.raise_for_status()

            self.status = 2
            log.debug('Opening zipfile')
            z = zipfile.ZipFile(BytesIO(r.content))
            self.status = 3
            log.debug('Extracting zipfile')
            tmp_dir = get_temp_dir()
            z.extractall(tmp_dir)
            folder_name = os.path.join(tmp_dir, z.namelist()[0])[:-1]
            if not os.path.isdir(folder_name):
                self.status = 11
                log.info('Extracted contents of zipfile not found in temp folder')
                self.pause()
                return False
            self.status = 4
            log.debug('Replacing files')
            if self.update_source(folder_name, constants.BASE_DIR):
                self.status = 6
                log.debug('Preparing restart of server')
                time.sleep(2)
                self.web_server.stop(True)
                self.status = 7
                time.sleep(2)
                return True
            else:
                self.status = 13

        except requests.exceptions.HTTPError as ex:
            log.error('HTTP Error %s', ex)
            self.status = 8
        except requests.exceptions.ConnectionError:
            log.error('Connection error')
            self.status = 9
        except requests.exceptions.Timeout:
            log.error('Timeout while establishing connection')
            self.status = 10
        except (requests.exceptions.RequestException, zipfile.BadZipFile):
            self.status = 11
            log.error('General error')
        except (IOError, OSError) as ex:
            self.status = 12
            log.error('Possible Reason for error: update file could not be saved in temp dir')
            log.error_or_exception(ex)
        self.pause()
        return False

    def run(self):
        while True:
            self.can_run.wait()
            if self.status > -1:
                if self.do_work():
                    break   # stop loop and end thread for restart
            else:
                break

    def pause(self):
        self.can_run.clear()

    # should just resume the thread
    def resume(self):
        self.can_run.set()

    def stop(self):
        self.status = -2
        self.can_run.set()

    def get_update_status(self):
        return self.status

    @classmethod
    def file_to_list(cls, filelist):
        return [x.strip() for x in open(filelist, 'r') if not x.startswith('#EXT')]

    @classmethod
    def one_minus_two(cls, one, two):
        return [x for x in one if x not in set(two)]

    @classmethod
    def reduce_dirs(cls, delete_files, new_list):
        new_delete = []
        for filename in delete_files:
            parts = filename.split(os.sep)
            sub = ''
            for part in parts:
                sub = os.path.join(sub, part)
                if sub == '':
                    sub = os.sep
                count = 0
                for song in new_list:
                    if song.startswith(sub):
                        count += 1
                        break
                if count == 0:
                    if sub != '\\':
                        new_delete.append(sub)
                    break
        return list(set(new_delete))

    @classmethod
    def reduce_files(cls, remove_items, exclude_items):
        rf = []
        for item in remove_items:
            if not item.startswith(exclude_items):
                rf.append(item)
        return rf

    @classmethod
    def check_permissions(cls, root_src_dir, root_dst_dir, log_function):
        access = True
        remove_path = len(root_src_dir) + 1
        for src_dir, __, files in os.walk(root_src_dir):
            root_dir = os.path.join(root_dst_dir, src_dir[remove_path:])
            # Skip non-existing folders on check
            if not os.path.isdir(root_dir):
                continue
            if not os.access(root_dir, os.R_OK | os.W_OK):
                log_function("Missing permissions for {}".format(root_dir))
                access = False
            for file_ in files:
                curr_file = os.path.join(root_dir, file_)
                # Skip non-existing files on check
                if not os.path.isfile(curr_file):  # or curr_file.startswith('.'):
                    continue
                if not os.access(curr_file, os.R_OK | os.W_OK):
                    log_function("Missing permissions for {}".format(curr_file))
                    access = False
        return access

    @classmethod
    def move_all_files(cls, root_src_dir, root_dst_dir):
        permission = None
        new_permissions = os.stat(root_dst_dir)
        log.debug('Performing Update on OS-System: %s', sys.platform)
        change_permissions = not (sys.platform == "win32" or sys.platform == "darwin")
        # Skip permission changes entirely in network share mode
        try:
            nsm = os.getenv("NETWORK_SHARE_MODE", "false").strip().lower() in ("1", "true", "yes", "on")
            if nsm:
                change_permissions = False
                log.debug('NETWORK_SHARE_MODE=true detected; skipping chown operations during update')
        except Exception:
            pass
        for src_dir, __, files in os.walk(root_src_dir):
            dst_dir = src_dir.replace(root_src_dir, root_dst_dir, 1)
            if not os.path.exists(dst_dir):
                try:
                    os.makedirs(dst_dir)
                    log.debug('Create directory: {}'.format(dst_dir))
                except OSError as e:
                    log.error('Failed creating folder: {} with error {}'.format(dst_dir, e))
                if change_permissions:
                    try:
                        os.chown(dst_dir, new_permissions.st_uid, new_permissions.st_gid)
                    except OSError as e:
                        old_permissions = os.stat(dst_dir)
                        log.error('Failed changing permissions of %s. Before: %s:%s After %s:%s error: %s',
                                  dst_dir, old_permissions.st_uid, old_permissions.st_gid,
                                  new_permissions.st_uid, new_permissions.st_gid, e)
            for file_ in files:
                src_file = os.path.join(src_dir, file_)
                dst_file = os.path.join(dst_dir, file_)
                if os.path.exists(dst_file):
                    if change_permissions:
                        permission = os.stat(dst_file)
                    try:
                        os.remove(dst_file)
                        log.debug('Remove file before copy: {}'.format(dst_file))
                    except OSError as e:
                        log.error('Failed removing file: {} with error {}'.format(dst_file, e))
                else:
                    if change_permissions:
                        permission = new_permissions
                try:
                    shutil.move(src_file, dst_dir)
                    log.debug('Move File %s to %s', src_file, dst_dir)
                except OSError as ex:
                    log.error('Failed moving file from {} to {} with error {}'.format(src_file, dst_dir, ex))
                if change_permissions:
                    try:
                        os.chown(dst_file, permission.st_uid, permission.st_gid)
                    except OSError as e:
                        old_permissions = os.stat(dst_file)
                        log.error('Failed changing permissions of %s. Before: %s:%s After %s:%s error: %s',
                                  dst_file, old_permissions.st_uid, old_permissions.st_gid,
                                  permission.st_uid, permission.st_gid, e)
        return

    def update_source(self, source, destination):
        # destination files
        old_list = list()
        exclude = self._add_excluded_files(log.info)
        additional_path = self.is_venv()
        if additional_path:
            exclude.append(additional_path)
        exclude = tuple(exclude)
        # check if we are in a package, rename cps.py to __init__.py and __main__.py
        if constants.HOME_CONFIG:
            shutil.copy(os.path.join(source, 'cps.py'), os.path.join(source, '__main__.py'))
            shutil.move(os.path.join(source, 'cps.py'), os.path.join(source, '__init__.py'))

        for root, dirs, files in os.walk(destination, topdown=True):
            for name in files:
                old_list.append(os.path.join(root, name).replace(destination, ''))
            for name in dirs:
                old_list.append(os.path.join(root, name).replace(destination, ''))
        # source files
        new_list = list()
        for root, dirs, files in os.walk(source, topdown=True):
            for name in files:
                new_list.append(os.path.join(root, name).replace(source, ''))
            for name in dirs:
                new_list.append(os.path.join(root, name).replace(source, ''))

        delete_files = self.one_minus_two(old_list, new_list)

        rf = self.reduce_files(delete_files, exclude)

        remove_items = self.reduce_dirs(rf, new_list)

        if self.check_permissions(source, destination, log.debug):
            self.move_all_files(source, destination)

            for item in remove_items:
                item_path = os.path.join(destination, item[1:])
                if os.path.isdir(item_path):
                    log.debug("Delete dir %s", item_path)
                    shutil.rmtree(item_path, ignore_errors=True)
                else:
                    try:
                        os.remove(item_path)
                        log.debug("Delete file %s", item_path)
                    except OSError:
                        log.debug("Could not remove: %s", item_path)
            shutil.rmtree(source, ignore_errors=True)
            return True
        else:
            log.debug("Permissions missing for update")
            return False

    @staticmethod
    def is_venv():
        if (hasattr(sys, 'real_prefix')) or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix):
            return os.sep + os.path.relpath(sys.prefix, constants.BASE_DIR)
        else:
            return False

    @classmethod
    def _nightly_version_info(cls):
        if is_sha1(constants.NIGHTLY_VERSION[0]) and len(constants.NIGHTLY_VERSION[1]) > 0:
            log.debug("Nightly version: {}, {}".format(constants.NIGHTLY_VERSION[0], constants.NIGHTLY_VERSION[1]))
            return {'version': constants.NIGHTLY_VERSION[0], 'datetime': constants.NIGHTLY_VERSION[1]}
        return False

    @classmethod
    def _stable_version_info(cls):
        log.debug("Stable version: {}".format(constants.STABLE_VERSION))
        return {'version': constants.STABLE_VERSION }

    @classmethod
    def dry_run(cls):
        cls._add_excluded_files(print)
        cls.check_permissions(constants.BASE_DIR, constants.BASE_DIR, print)
        print("\n*** Finished ***")

    @staticmethod
    def _populate_parent_commits(update_data, status, tz, parents):
        try:
            parent_commit = update_data['parents'][0]
            # limit the maximum search depth
            remaining_parents_cnt = 10
        except (IndexError, KeyError):
            remaining_parents_cnt = None
            parent_commit = None

        if remaining_parents_cnt is not None:
            while True:
                if remaining_parents_cnt == 0:
                    break

                # check if we are more than one update behind if so, go up the tree
                if parent_commit['sha'] != status['current_commit_hash']:
                    try:
                        headers = {'Accept': 'application/vnd.github.v3+json'}
                        r = requests.get(parent_commit['url'], headers=headers, timeout=10)
                        r.raise_for_status()
                        parent_data = r.json()

                        parent_commit_date = datetime.datetime.strptime(
                            parent_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
                        parent_commit_date = format_datetime(
                            parent_commit_date, format='short')

                        parents.append([parent_commit_date,
                                        parent_data['message'].replace('\r\n', '<p>').replace('\n', '<p>')])
                        parent_commit = parent_data['parents'][0]
                        remaining_parents_cnt -= 1
                    except Exception:
                        # it isn't crucial if we can't get information about the parent
                        break
                else:
                    # parent is our current version
                    break
        return parents

    @staticmethod
    def _load_nightly_data(repository_url, commit, status):
        update_data = dict()
        try:
            headers = {'Accept': 'application/vnd.github.v3+json'}
            r = requests.get(repository_url + '/git/commits/' + commit['object']['sha'],
                             headers=headers,
                             timeout=10)
            r.raise_for_status()
            update_data = r.json()
        except requests.exceptions.HTTPError as e:
            status['message'] = _('HTTP Error') + ' ' + str(e)
        except requests.exceptions.ConnectionError:
            status['message'] = _('Connection error')
        except requests.exceptions.Timeout:
            status['message'] = _('Timeout while establishing connection')
        except (requests.exceptions.RequestException, ValueError):
            status['message'] = _('General error')
        return status, update_data

    @staticmethod
    def _add_excluded_files(log_function):
        excluded_files = [
            os.sep + 'app.db', os.sep + 'calibre-web.log1', os.sep + 'calibre-web.log2', os.sep + 'gdrive.db',
            os.sep + 'vendor', os.sep + 'calibre-web.log', os.sep + '.git', os.sep + 'client_secrets.json',
            os.sep + 'gdrive_credentials', os.sep + 'settings.yaml', os.sep + 'venv', os.sep + 'virtualenv',
            os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2', os.sep + '.key',
            os.sep + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR',
            os.sep + 'gmail.json', os.sep + 'exclude.txt', os.sep + 'cps' + os.sep + 'cache'
        ]
        try:
            with open(os.path.join(constants.BASE_DIR, "exclude.txt"), "r") as f:
                lines = f.readlines()
            for line in lines:
                processed_line = line.strip("\n\r ").strip("\"'").lstrip("\\/ ").\
                    replace("\\", os.sep).replace("/", os.sep)
                if os.path.exists(os.path.join(constants.BASE_DIR, processed_line)):
                    excluded_files.append(os.sep + processed_line)
                else:
                    log_function("File list for updater: {} not found".format(line))
        except (PermissionError, FileNotFoundError):
            log_function("Excluded file list for updater not found, or not accessible")
        return excluded_files

    def _nightly_available_updates(self, request_method):
        tz = datetime.timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
        if request_method == "GET":
            repository_url = _REPOSITORY_API_URL
            status, commit = self._load_remote_data(repository_url + '/git/refs/heads/master')
            parents = []
            if status['message'] != '':
                return json.dumps(status)
            if 'object' not in commit or 'url' not in commit['object']:
                status['message'] = _('Unexpected data while reading update information')
                return json.dumps(status)
            try:
                if commit['object']['sha'] == status['current_commit_hash']:
                    status.update({
                        'update': False,
                        'success': True,
                        'message': _('No update available. You already have the latest version installed')
                    })
                    return json.dumps(status)
            except (TypeError, KeyError):
                status['message'] = _('Unexpected data while reading update information')
                return json.dumps(status)

            # a new update is available
            status['update'] = True
            status, update_data = self._load_nightly_data(repository_url, commit, status)

            if status['message'] != '':
                return json.dumps(status)

            # if 'committer' in update_data and 'message' in update_data:
            try:
                log.debug("A new update is available.")
                status['success'] = True
                status['message'] = _(
                    'A new update is available. Click on the button below to update to the latest version.')

                new_commit_date = datetime.datetime.strptime(
                    update_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
                parents.append(
                    [
                        format_datetime(new_commit_date, format='short'),
                        update_data['message'],
                        update_data['sha']
                    ]
                )
                # it only makes sense to analyze the parents if we know the current commit hash
                if status['current_commit_hash'] != '':
                    parents = self._populate_parent_commits(update_data, status, tz, parents)
                status['history'] = parents[::-1]
            except (IndexError, KeyError):
                status['success'] = False
                status['message'] = _('Could not fetch update information')
                log.error("Could not fetch update information")
            return json.dumps(status)
        return ''

    def _stable_updater_set_status(self, i, newer, status, parents, commit):
        if i == -1 and newer is False:
            status.update({
                'update': True,
                'success': True,
                'message': _(
                    'Click on the button below to update to the latest stable version.'),
                'history': parents
            })
            self.updateFile = commit[0]['zipball_url']
        elif i == -1 and newer is True:
            status.update({
                'update': True,
                'success': True,
                'message': _('A new update is available. Click on the button below to '
                             'update to version: %(version)s', version=commit[0]['tag_name']),
                'history': parents
            })
            self.updateFile = commit[0]['zipball_url']
        return status

    def _stable_updater_parse_major_version(self, commit, i, parents, current_version, status):
        if int(commit[i + 1]['tag_name'].split('.')[1]) == current_version[1]:
            parents.append([commit[i]['tag_name'],
                            commit[i]['body'].replace('\r\n', '<p>').replace('\n', '<p>')])
            status.update({
                'update': True,
                'success': True,
                'message': _(u'A new update is available. Click on the button below to '
                             u'update to version: %(version)s', version=commit[i]['tag_name']),
                'history': parents
            })
            self.updateFile = commit[i]['zipball_url']
        else:
            parents.append([commit[i + 1]['tag_name'],
                            commit[i + 1]['body'].replace('\r\n', '<p>').replace('\n', '<p>')])
            status.update({
                'update': True,
                'success': True,
                'message': _(u'A new update is available. Click on the button below to '
                             u'update to version: %(version)s', version=commit[i + 1]['tag_name']),
                'history': parents
            })
            self.updateFile = commit[i + 1]['zipball_url']
        return status, parents

    def _stable_available_updates(self, request_method):
        status = None
        if request_method == "GET":
            parents = []
            # repository_url = 'https://api.github.com/repos/flatpak/flatpak/releases'  # test URL
            repository_url = _REPOSITORY_API_URL + '/releases?per_page=100'
            status, commit = self._load_remote_data(repository_url)
            if status['message'] != '':
                return json.dumps(status)
            if not commit:
                status['success'] = True
                status['message'] = _(u'No release information available')
                return json.dumps(status)
            version = status['current_commit_hash']
            current_version = status['current_commit_hash'].split('.')

            # we are already on newest version, no update available
            if 'tag_name' not in commit[0]:
                status['message'] = _(u'Unexpected data while reading update information')
                log.error("Unexpected data while reading update information")
                return json.dumps(status)
            if commit[0]['tag_name'] == version:
                status.update({
                    'update': False,
                    'success': True,
                    'message': _(u'No update available. You already have the latest version installed')
                })
                return json.dumps(status)

            i = len(commit) - 1
            newer = False
            while i >= 0:
                if 'tag_name' not in commit[i] or 'body' not in commit[i] or 'zipball_url' not in commit[i]:
                    status['message'] = _(u'Unexpected data while reading update information')
                    return json.dumps(status)
                major_version_update = int(commit[i]['tag_name'].split('.')[0])
                minor_version_update = int(commit[i]['tag_name'].split('.')[1])
                patch_version_update = int(commit[i]['tag_name'].split('.')[2])

                current_version[0] = int(current_version[0])
                current_version[1] = int(current_version[1])
                try:
                    current_version[2] = int(current_version[2])
                except ValueError:
                    current_version[2] = int(current_version[2].replace("b", "").split(' ')[0])-1

                # Check if major versions are identical search for newest non-equal commit and update to this one
                if major_version_update == current_version[0]:
                    if (minor_version_update == current_version[1] and
                            patch_version_update > current_version[2]) or \
                            minor_version_update > current_version[1]:
                        parents.append([commit[i]['tag_name'], commit[i]['body'].replace('\r\n', '<p>')])
                        newer = True
                    i -= 1
                    continue
                if major_version_update < current_version[0]:
                    i -= 1
                    continue
                if major_version_update > current_version[0]:
                    # found update to last version before major update, unless current version is on last version
                    # before major update
                    if i == (len(commit) - 1):
                        i -= 1
                    status, parents = self._stable_updater_parse_major_version(commit,
                                                                               i,
                                                                               parents,
                                                                               current_version,
                                                                               status)
                    break

            status = self._stable_updater_set_status(i, newer, status, parents, commit)
        return json.dumps(status)

    def _get_request_path(self):
        if self.config.config_updatechannel == constants.UPDATE_STABLE:
            return self.updateFile
        return _REPOSITORY_API_URL + '/zipball/master'

    def _load_remote_data(self, repository_url):
        status = {
            'update': False,
            'success': False,
            'message': '',
            'current_commit_hash': ''
        }
        commit = None
        version = self.get_current_version_info()
        if version is False:
            status['current_commit_hash'] = _(u'Unknown')
        else:
            status['current_commit_hash'] = version['version']
        try:
            headers = {'Accept': 'application/vnd.github.v3+json'}
            r = requests.get(repository_url, headers=headers, timeout=10)
            commit = r.json()
            r.raise_for_status()
        except requests.exceptions.HTTPError as e:
            if commit:
                if 'message' in commit:
                    status['message'] = _(u'HTTP Error') + ': ' + commit['message']
            else:
                status['message'] = _(u'HTTP Error') + ': ' + str(e)
        except requests.exceptions.ConnectionError as e:
            status['message'] = _(u'Connection error')
        except requests.exceptions.Timeout:
            status['message'] = _(u'Timeout while establishing connection')
        except (requests.exceptions.RequestException, ValueError):
            status['message'] = _(u'General error')
        log.debug('Updater status: {}'.format(status['message'] or "OK"))
        return status, commit
